Skip to content

feat(claude-agent-sdk): bridge defer to AG-UI interrupt/resume#2181

Open
Sri01728 wants to merge 5 commits into
ag-ui-protocol:mainfrom
Sri01728:feat/claude-adapter-interrupt-resume
Open

feat(claude-agent-sdk): bridge defer to AG-UI interrupt/resume#2181
Sri01728 wants to merge 5 commits into
ag-ui-protocol:mainfrom
Sri01728:feat/claude-adapter-interrupt-resume

Conversation

@Sri01728

@Sri01728 Sri01728 commented Jul 14, 2026

Copy link
Copy Markdown

What

Bridges the Claude Agent SDK defer primitive to the AG-UI interrupt/resume contract in the ag-ui-claude-sdk Python adapter.

Today, when a PreToolUse hook returns permissionDecision: "defer", the run halts and the terminating ResultMessage carries a DeferredToolUse, but the adapter has no way to surface that as an AG-UI RunFinishedInterruptOutcome, and it ignores RunAgentInput.resume[]. LangGraph (ag_ui_langgraph/interrupts.py) and the .NET reference already translate their native halt into the interrupt contract; this brings the Claude adapter in line.

No framework does true in-flight coroutine suspension — every "suspend/resume" is halt-at-boundary + resume-from-persisted-state. Claude's equivalent is defer; this PR is a thin translation layer over it, not a new suspension mechanism.

Changes

  • interrupts.py (new): deferred_tool_use_to_interrupt() maps DeferredToolUse -> ag_ui.core.Interrupt. The interrupt id (approval-request id) is intentionally distinct from tool_call_id (the frozen call id), matching the reference contract. response_schema is pulled best-effort from the run's declared tools; a missing schema never fails the run.
  • adapter.py:
    • New constructor flag emit_interrupt_outcome: bool = False. When on, a deferred tool becomes a RunFinishedInterruptOutcome on RUN_FINISHED, and RunAgentInput.resume[] is honoured. Default-off for back-compat (a client that predates the contract must not be handed an outcome it can't resume) — mirrors LangGraph's emit_interrupt_outcome.
    • Captures deferred_tool_use from the ResultMessage.
    • Records resume verdicts per thread keyed by the frozen tool-use id (_ingest_resume), exposed to the caller's re-fired PreToolUse hook via resume_verdict_for().
  • examples/agents/interrupt.py (new) + server.py/__init__.py: a /interrupt dojo agent gating a destructive delete_file tool behind the full defer -> interrupt -> resume loop (Strands/Mastra have an interrupt example; Claude didn't).
  • tests/test_interrupts.py (new): 14 unit tests.
  • README: feature note + example row.

Resume-payload semantics

On resume, the persisted Claude session continues (already keyed by thread_id), so the same frozen tool call re-fires PreToolUse. The hook reads the resume verdict: resolved -> allow (frozen args), cancelled -> deny. Executed args stay bound to DeferredToolUse.input; the resume payload carries only the verdict, so it cannot swap execution args.

Open question for maintainers

Unlike the .NET runtime, the Claude SDK has no native "resolve deferred approval and execute" API. This PR routes the verdict to the re-fired PreToolUse hook via adapter-owned per-run state (resume_verdict_for). If maintainers would prefer a different resume seam (e.g. injecting a synthetic tool result), happy to adjust — this is the one design point I didn't want to guess on.

Testing

  • python -m pytest in integrations/claude-agent-sdk/python: 120 existing + 14 new pass.
  • Tests cover emit-on-defer, opt-out, resume ingest (resolved/cancelled), interrupt-id round-trip, and the frozen-args security invariant. No LLM calls (pure translation + verdict-recording).

Depends on the interrupt/resume protocol types on main (Interrupt, ResumeEntry, RunFinishedInterruptOutcome, RunAgentInput.resume).

Closes #2180

The Claude Agent SDK adapter had no way to surface a paused tool call as an
AG-UI interrupt or to honour resume verdicts, so a run that deferred a tool
(via a PreToolUse hook returning permissionDecision: defer) simply ended with
no standard signal a client could act on. LangGraph and the .NET reference
already translate their native halt into the interrupt contract; this brings
the Claude adapter in line.

- interrupts.py: translate DeferredToolUse -> ag_ui.core.Interrupt (approval id
  distinct from tool_call_id, best-effort response_schema from the run tools).
- adapter: emit RunFinishedInterruptOutcome on defer and record
  RunAgentInput.resume[] verdicts keyed by the frozen tool-use id, read back by
  the caller's re-fired PreToolUse hook via resume_verdict_for(). Gated behind
  emit_interrupt_outcome (default off) for back-compat, mirroring LangGraph.
- Args stay bound to the frozen DeferredToolUse.input; resume carries only the
  verdict, so it cannot swap execution args.
- Add /interrupt dojo example and unit tests.
@Sri01728
Sri01728 marked this pull request as ready for review July 14, 2026 01:14
@Sri01728
Sri01728 requested a review from a team as a code owner July 14, 2026 01:14
salahari and others added 2 commits July 13, 2026 21:36
…-fires

The live worker's client.query(session_id=...) is only a per-message
routing tag; it does not trigger SDK session-resume, so the frozen
DeferredToolUse never re-fired PreToolUse on resume. On a resume run,
evict the live worker and build a fresh one seeded with
ClaudeAgentOptions(resume=<claude_session_id>) so the persisted session
materializes at connect and replays the frozen call through the hook.
Capture worker.session_id per-thread after each run to seed resume.

Validated live on Bedrock (haiku-4-5 + sonnet-4-5), approve + reject:
frozen call re-fires, executes with unswapped args on approve, blocked
on reject. 120 unit tests pass.
@Sri01728

Copy link
Copy Markdown
Author

Verified local testing (live against Bedrock)

Validated this bridge end-to-end live against real Bedrock (not mocks), driving the actual adapter code path (adapter.run() with RunAgentInput + resume[]), not just the raw SDK.

Matrix: 2 models × 2 verdicts = 4/4 PASS

Model Verdict Emit Frozen call re-fired on resume Result
us.anthropic.claude-haiku-4-5 APPROVE tool executed with frozen args {'path': '/tmp/report.txt'}
us.anthropic.claude-haiku-4-5 REJECT tool did not execute (non-vacuous)
us.anthropic.claude-sonnet-4-5 APPROVE tool executed with frozen args
us.anthropic.claude-sonnet-4-5 REJECT tool did not execute

What was proven:

  • Emit path — a gated call returning permissionDecision: "defer" ends the run cleanly with stop_reason=tool_deferred and a real DeferredToolUse{id, name, input}, which translates to RunFinishedInterruptOutcome.
  • Resume path — on resume with RunAgentInput.resume[], the exact frozen tool call re-fires the PreToolUse hook (same tool_use_id, same args); the hook reads the verdict and allows/denies.
  • Frozen-args guaranteeDeferredToolUse.input is immutable; the model cannot swap args after approval (args frozen-match: True).
  • Reject is real — the REJECT path blocks execution, it is not merely absent.

One fix needed to make resume work through the adapter: the initial version reused the live in-process ClaudeSDKClient, whose query(session_id=...) is only a routing tag and does not trigger SDK session-resume. Real session-resume requires ClaudeAgentOptions(resume=session_id) at connect() time. The adapter now evicts the live worker on a resume run and spins up a fresh worker with the resume option so the frozen deferred call re-fires. After that, all 4 matrix cases passed.

Tests: 134 total (120 existing + 14 new interrupt unit tests), all green, no regressions.

Behavior worth flagging for reviewers: defer ends the run per deferred tool, so a turn with multiple gated tools becomes a chain of runs rather than a single interrupt carrying multiple entries. Callers that need at-most-once execution across that chain must track it themselves; the adapter does not.

@enginerd-kr

enginerd-kr commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Ran the interrupt example end-to-end (approve and reject) through the adapter instead of reviewing statically. Setup: claude-agent-sdk==0.2.123 / bundled CLI 2.1.215 (pip's current latest, past the >=0.1.12 floor in pyproject.toml), tested twice — once via an OAuth-logged-in CLI session, once with a real ANTHROPIC_API_KEY — to rule out anything auth-path-specific. Same result both times: two bugs, both need fixing before this actually gates the tool.

Bug 1 — the gate never engages; delete_file runs immediately, no approval step at all

TL;DR: gate_hook's response is missing one required field, so the CLI silently discards the "defer" decision and just runs the tool. One-line-×3 fix.

What I tested: the example as-is, first turn only — ask the agent to delete a file, watch whether it pauses for approval.

What happened: it doesn't pause. delete_file executes on the very first turn — no interrupt, no error surfaced anywhere. Root cause is in gate_hook:

# examples/agents/interrupt.py
return {"hookSpecificOutput": {"permissionDecision": "defer"}}

hookSpecificOutput requires hookEventName. The CLI validates hook responses with Zod, and without that field it rejects the response outright (shows up in stderr only, never surfaced to the caller):

Error in hook callback hook_0: ...
ZodError: [{ "code": "invalid_union", ... "path": ["hookEventName"], "message": "Invalid input: expected \"PreToolUse\"" ... }]

The rejected decision is dropped silently, so the CLI proceeds as if no decision was returned — tool executes. Confirmed by adding the field back locally and rerunning: defer starts working immediately.

Where to fix: examples/agents/interrupt.py, gate_hook — add "hookEventName": "PreToolUse" to all three hookSpecificOutput returns (defer, allow, deny).

Bug 2 — after fixing Bug 1, resume ignores the verdict and just re-defers the same call

TL;DR: gate_hook does re-fire on resume and does report allow/deny correctly — but something else, logged as a different hook source ("settings", not our callback), makes the real decision and unconditionally re-defers the exact same call regardless of what we returned. Approve and reject produce an identical outcome: nothing executes, and the caller just gets a second, indistinguishable copy of the same interrupt.

What I tested: with Bug 1 patched locally, the full loop — defer, then resume with status: "resolved" (approve), and separately with status: "cancelled" (reject).

What happened: run 1 (defer) works correctly now — RunFinishedInterruptOutcome comes back with the right interrupt_id/tool_call_id, tool doesn't execute. Resume (run 2) doesn't error and doesn't hang — it completes cleanly — but it re-issues the same interrupt instead of acting on the verdict. I patched parse_message to log the raw CLI protocol traffic to see why. For the approve case:

{'type': 'attachment', 'attachment': {
    'type': 'hook_success',
    'hookName': 'PreToolUse:Callback',   # <-- our gate_hook. Ran fine, reported success.
    'toolUseID': 'toolu_01Sz41...', 'hookEvent': 'PreToolUse', 'content': '', 'command': 'callback'}}

{'type': 'attachment', 'attachment': {
    'type': 'hook_deferred_tool',
    'hookName': 'settings',              # <-- NOT our callback. This is what actually decided.
    'toolUseID': 'toolu_01Sz41...', 'toolName': 'mcp__file_ops__delete_file',
    'toolInput': {'path': '/tmp/secret.txt'}, 'hookEvent': 'PreToolUse', 'permissionMode': 'default'}}

{'type': 'result', 'stop_reason': 'tool_deferred',   # <-- deferred AGAIN, same tool call
    'duration_ms': 12, 'total_cost_usd': 0,          # <-- no model turn ran; this is instant/free
    'deferred_tool_use': {'id': 'toolu_01Sz41...', 'name': 'mcp__file_ops__delete_file', ...}}

Reran with reject (cancelled): byte-for-byte the same sequence, same hookName: "settings", same re-defer. Our verdict never reaches whatever's actually deciding.

Net effect for the caller: RUN_FINISHED comes back with a second RunFinishedInterruptOutcome, same interrupt_id, for both verdicts. delete_file never executes either way — reject happens to land on the safe outcome, but not because deny was enforced; approve silently never executes, and there's nothing distinguishing "stuck re-deferring" from a legitimate fresh approval request. A client that just resubmits the same verdict would loop forever.

Where to look: resume_verdict_for() in adapter.py assumes that resuming with ClaudeAgentOptions(resume=...) re-fires gate_hook and that this SDK version acts on what it returns. That assumption is false for create_sdk_mcp_server-hosted tools on the currently pinned SDK — confirmed above.

I tried the obvious fix: set permission_mode to "bypassPermissions", then separately to "dontAsk", on the resumed connection. Ruled out both — the attachment confirms the mode was applied each time, but the outcome doesn't change (same re-defer, same hookName: "settings"). So this isn't something you can configure your way out of via ClaudeAgentOptions.

One more clue, and this one worries me more: every re-defer's ResultMessage shows num_turns: 10 and near-zero duration/cost. No model call is happening. That smells like the CLI just replaying the session's last saved state ("still deferred") on reconnect, rather than actually re-checking the pending call against our new verdict. If that's right, there may be nothing adapter.py can do here — it'd need to be fixed upstream in the SDK/CLI itself. Worth asking the Claude Agent SDK maintainers directly whether resume is even supposed to re-evaluate PreToolUse for a deferred create_sdk_mcp_server call. I don't have a fix, just the repro and two ruled-out leads.


Happy to share the repro scripts. Neither bug touches the adapter's own AG-UI translation logic, so I'd guess this was last tested against a different claude-agent-sdk version — worth naming the exact version in the PR description. A live (env-gated) integration test covering resume would also help, since the current unit tests are pure translation tests and wouldn't catch either of these.

Merge conflict resolution:
- adapter.py: integrate both the interrupt/resume outcome logic (ours) and
  the RunErrorEvent terminal event handling (ag-ui-protocol#2145 from main). On API error
  turns, RUN_ERROR is emitted; on success, RUN_FINISHED with interrupt
  outcome as before.

Bug fix (reported by @enginerd-kr):
- examples/agents/interrupt.py: add required "hookEventName": "PreToolUse"
  to all hookSpecificOutput returns. Without this field the CLI's Zod
  validation silently rejects the hook response and the tool runs ungated.
@Sri01728

Sri01728 commented Jul 20, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough live testing @enginerd-kr — both bugs are real.

Bug 1 — fixed

Added "hookEventName": "PreToolUse" to all three hookSpecificOutput returns in the example's gate_hook. Pushed in d792d8e.

Bug 2 — confirmed upstream SDK bug (not fixable in this adapter)

Investigated this thoroughly. It's a known registration-ordering race in the Claude Agent SDK:

Root cause: On --resume=<session_id>, the CLI's deferred-tool replay pass fires during subprocess initialization, before the SDK's initialize control message (carrying callback hooks and MCP server registrations) arrives over stdin. So the only active hook at replay time is hookName: "settings" (the CLI's built-in permission layer), which re-defers unconditionally.

Fix status: PR #1008 on the Python SDK is open (restructures startup to send initialize before the CLI enters its main loop). Not merged yet. No TS fix PR exists.

Interim workaround for callers:

  • Approve: resume the session (tool executes because no deny hook fires in time)
  • Reject: don't resume the session at all (the tool never runs)

This is the same workaround suggested in issue #993 and works end-to-end today, despite the race. Should I encode this approve-resumes/reject-skips pattern directly in the adapter (bypassing the hook re-evaluation path entirely), or wait for the upstream fix and document the limitation?

Also in this push

Resolved merge conflicts with main: integrated the RunErrorEvent terminal handling (#2145) alongside our interrupt outcome logic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Human-in-the-loop interrupt/resume support in Claude Agent SDK integration

2 participants